Skip to content

Python: agent-hooks interception contract as a first-class experimental core feature - #7515

Open
MohammadHaroonAbuomar wants to merge 9 commits into
microsoft:mainfrom
MohammadHaroonAbuomar:mhabuomar/agent-hooks-first-class
Open

Python: agent-hooks interception contract as a first-class experimental core feature#7515
MohammadHaroonAbuomar wants to merge 9 commits into
microsoft:mainfrom
MohammadHaroonAbuomar:mhabuomar/agent-hooks-first-class

Conversation

@MohammadHaroonAbuomar

@MohammadHaroonAbuomar MohammadHaroonAbuomar commented Aug 4, 2026

Copy link
Copy Markdown

Motivation & Context

Runtime controls for agents (policy engines, approval flows, information-flow checks, budget guards, audit pipelines) currently require one adapter per framework, and no framework defines what happens when a guardrail callback fails or lets a control author verify "supported" claims. AGENT-HOOKS-0.1 is a framework-neutral interception contract addressing this: eight interception points, a three-verdict model (allow / deny with liftable approval / transform), fail-closed host obligations, payload-free audit records, and a conformance test kit.

PR #7444 proposed this as an external adapter package. Maintainer feedback asked for a first-class experimental feature in core instead, with a single public factory, private middleware, an opt-in extra, and corrections to transform write-back, content preservation, and streaming semantics. This PR supersedes #7444 and implements exactly that design.

Description & Review Guide

  • What are the major changes?
    • New agent_framework/_agent_hooks.py: one public factory, agent_hooks_middleware(...), returning a private agent/chat/function middleware trio (one object per middleware category, per categorize_middleware()). Partial installs and stacked trios fail closed with explicit errors, so a caller cannot accidentally install part of the control contract.
    • All eight interception points mapped to native seams: input/output at the agent seam, pre/post_model_call at the chat seam, pre/post_tool_call at the function seam, agent_startup/agent_shutdown bracketing each run. Transforms write back into the native contexts (messages, arguments, results) as Content objects; an unappliable transform raises rather than proceeding untransformed.
    • Fail-closed enforcement: MiddlewareTermination short-circuits are guarded at every seam (a substituted result passes the relevant interception point before egress); enforcement-layer failures halt the run; interceptor crashes surface as host_error denies.
    • Streaming is fully buffered (spec buffered_output semantics): no update egresses before the post_model_call/output verdicts; a deny at pull time releases zero updates; run state stays active across lazy pulls (ResponseStream.from_awaitable + result/cleanup hooks) with cleanup on every exit path.
    • Session scoping: per-run by default, or host-owned via emitter/builder parameters for one audit session spanning multiple runs.
    • agent-hooks-sdk is an opt-in agent-hooks extra (not in all), lazy-imported per the _mcp.py pattern; core imports cleanly without it. ExperimentalFeature.AGENT_HOOKS + @experimental, lazy root export, typing surface, PACKAGE_STATUS.md entry.
  • What is the impact of these changes? No behavior change for anyone not calling the factory. New optional extra; no new required dependencies. 55 new tests built on the real Agent/mock-client flows (deny-before-execution, transform write-back, rich-content preservation, complete streaming ordering, error cleanup, concurrency isolation, nested agents, importability without the SDK). Full core suite, ruff, strict pyright, and uv lock --check pass locally.
  • What do you want reviewers to focus on? The MiddlewareTermination guarding at the four seams; the buffered-streaming trade-off (callers get the stream API but updates arrive only after the output verdict — the only fully fail-closed option); tool-seam deny semantics (policy deny returns a reason-only error payload and the loop continues; host_error:* halts the run); and the sibling/stacking verification approach.

Known limitation to resolve before merge: agent-hooks-sdk on PyPI currently ships a linux-x86_64 wheel only, so uv sync --all-extras builds it from sdist elsewhere (macOS/Windows wheels are being published; will update this PR when live).

Related Issue

Supersedes #7444 (external-adapter draft, closed in favor of this first-class design per maintainer feedback).

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change.

Behavior change note

A bare middleware object passed to an agent constructor or assigned to the middleware attribute was previously ignored by run() (any non-sequence collapsed to no middleware); it now executes, matching the per-run middleware= parameter semantics.

Implement the AGENT-HOOKS-0.1 interception contract as a first-class
experimental feature in agent_framework core.

- Single public factory agent_hooks_middleware() returning a private
  agent/chat/function middleware trio (one object per middleware
  category); partial or stacked installs fail closed with loud errors.
- All eight interception points: input/output at the agent seam,
  pre/post_model_call at the chat seam, pre/post_tool_call at the
  function seam, agent_startup/agent_shutdown bracketing each run.
- Fail-closed enforcement throughout: transforms write back into the
  native contexts (messages, arguments, results) or raise; content is
  preserved as Content objects; MiddlewareTermination short-circuits
  are guarded at every seam; enforcement-layer failures halt the run;
  interceptor crashes surface as host_error denies.
- Streaming is fully buffered per spec buffered_output semantics: no
  update egresses before the post_model_call/output verdicts; a deny
  at pull time releases zero updates; run state stays active across
  lazy pulls with cleanup on every exit path.
- Session scoping: per-run by default (startup/shutdown bracket each
  run) or host-owned via emitter/builder parameters for one session
  spanning multiple runs.
- agent-hooks-sdk is an opt-in agent-hooks extra (not in all),
  lazy-imported per the _mcp.py pattern; core imports cleanly without
  it and the factory raises a clear ModuleNotFoundError.
- ExperimentalFeature.AGENT_HOOKS + @experimental decorator, lazy root
  export, typing surface, PACKAGE_STATUS.md entry.
- 55 tests built on real Agent/mock-client flows covering deny-before-
  execution, transform write-back, rich-content preservation, complete
  streaming ordering, error cleanup, concurrency isolation, nested
  agents, and importability without the optional SDK.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings August 4, 2026 18:43
@agent-framework-automation agent-framework-automation Bot added documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs python Usage: [Issues, PRs], Target: Python labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Python Test Coverage

Python Test Coverage Report •
FileStmtsMissCoverMissing
packages/core/agent_framework
   _agent_hooks.py75115179%266, 303, 306–309, 311–322, 325–326, 332–333, 360, 364, 397, 399, 407–408, 414, 419, 462, 480–481, 555, 564–565, 571–572, 579, 583, 594–595, 598–599, 603, 615–616, 619, 622, 632–634, 636–637, 643, 650–658, 661–662, 669–672, 674–675, 700, 704, 732–738, 741–743, 767–774, 776–777, 796, 798, 807, 809–814, 824, 835–838, 854, 865–874, 994–995, 1049–1050, 1098, 1108, 1122–1123, 1151–1152, 1167, 1169, 1181–1186, 1254–1255, 1300, 1361, 1384, 1395, 1400, 1410, 1413–1417, 1438
   _agents.py4534590%565, 577, 632, 1170, 1221, 1303–1307, 1406, 1436, 1473, 1565, 1593, 1606, 1658, 1660, 1669–1674, 1679, 1681, 1687–1688, 1695, 1697–1698, 1706–1707, 1710–1712, 1722–1727, 1731, 1736, 1738
   _clients.py1431291%325, 376, 539–542, 992, 994–995, 997–999
   _feature_stage.py1901094%110, 149, 165, 220, 231, 252, 290, 329, 379, 407
   _middleware.py4401696%377, 743, 750–751, 933, 1004, 1022, 1024, 1157, 1160, 1187, 1189, 1325, 1329, 1516, 1592
   _serialization.py1761293%356–357, 562, 572, 637, 640, 683–684, 688–689, 691, 693
   _sessions.py9445793%163, 175–176, 213, 224, 238, 262, 307, 312, 314, 324, 356, 368, 378, 487, 556–557, 1270–1274, 1289, 1319, 1356–1357, 1371, 1373, 1392, 1394, 1477, 1517, 1594, 1598, 1608, 1825, 1858–1859, 1864, 1879, 1957–1958, 1960, 2044, 2061, 2140, 2213, 2242, 2261, 2264, 2272–2273, 2285–2286, 2298, 2308, 2338
   _tools.py13768294%231–232, 409, 411, 424, 449–451, 459, 477, 491, 498, 505, 528, 530, 537, 545, 680, 714–716, 719–721, 723, 729, 780–782, 807, 833, 837, 875–877, 881, 1054, 1066, 1073–1076, 1097, 1105, 1119–1121, 1491, 1574, 1624, 1684–1685, 1742, 1789, 1796–1797, 1887, 1949–1950, 1980, 2076, 2090, 2093, 2106, 2109, 2132, 2139, 2148, 2152, 2177, 2211, 2279, 2308–2309, 2406, 2434, 2474, 2477, 2534, 2687, 2776, 3270
   _types.py12919992%63, 73, 127, 136, 155, 157, 161, 165, 167, 169, 171, 189, 193, 219, 241, 246, 251, 255, 285, 711–712, 893–894, 1352, 1427, 1462, 1482, 1492, 1686–1688, 1973–1978, 2003, 2058, 2063, 2073, 2081, 2088–2092, 2110, 2183, 2196, 2201, 2314, 2337, 2598, 2622, 2721, 2902–2903, 3005, 3288, 3341, 3360, 3399, 3410, 3412–3416, 3418, 3421–3429, 3439, 3604, 3611, 3615–3617, 3762, 3767, 3772, 3777, 3781, 3867–3869, 3898, 3986–3990
   observability.py10297992%448, 450–451, 454, 457, 460–461, 466–467, 473–474, 480–481, 488, 490–491, 494, 497, 500–501, 506–507, 513–514, 520–521, 528, 705–706, 916, 920–922, 924, 932–933, 937, 981, 983, 994–996, 998–1000, 1004, 1012, 1136–1137, 1372, 1659–1660, 1923, 1966–1967, 2156, 2384–2385, 2389, 2429–2430, 2434, 2475–2478, 2519–2520, 2671, 2674, 2686, 2703, 2707–2708, 2711, 2717, 2837, 3058, 3060
packages/core/agent_framework/_harness
   _agent.py132496%202, 625–626, 628
packages/foundry/agent_framework_foundry
   _agent.py2683487%124, 127, 251, 256, 265, 369, 477–478, 502, 505, 516–528, 532–533, 731–732, 735, 761, 771, 793, 881, 886, 890
TOTAL45648425290% 

Python Unit Test Overview

Tests Skipped Failures Errors Time
9261 34 💤 0 ❌ 0 🔥 2m 28s ⏱️

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an experimental, first-class implementation of the AGENT-HOOKS-0.1 interception/enforcement contract to the Python core package (agent-framework-core), including an opt-in extra for the SDK dependency and a comprehensive test suite validating fail-closed behavior across agent/chat/tool seams (including buffered streaming).

Changes:

  • Introduces agent_framework/_agent_hooks.py with the public agent_hooks_middleware(...) factory that returns an agent/chat/function middleware trio implementing all eight interception points and fail-closed semantics.
  • Adds the opt-in agent-hooks extra (agent-hooks-sdk>=0.1.0a4,<0.2) and updates exports + experimental feature registration/documentation.
  • Adds extensive unit tests covering deny/transform semantics, streaming buffering, short-circuit guarding, partial install detection, and optional-dependency importability.
Show a summary per file
File Description
python/uv.lock Adds the agent-hooks extra lock entries and locks agent-hooks-sdk 0.1.0a4.
python/packages/core/tests/core/test_agent_hooks.py New test suite for agent-hooks enforcement and semantics across seams (incl. streaming).
python/packages/core/pyrightconfig.dependency.json Excludes the new module from dependency-bound pyright checking.
python/packages/core/pyproject.toml Adds agent-hooks optional dependency extra (explicitly not part of all).
python/packages/core/agent_framework/_feature_stage.py Registers ExperimentalFeature.AGENT_HOOKS.
python/packages/core/agent_framework/_agent_hooks.py Implements the enforcement middleware trio + projections/write-back + buffering semantics.
python/packages/core/agent_framework/init.pyi Adds typing export for agent_hooks_middleware.
python/packages/core/agent_framework/init.py Adds lazy runtime export for agent_hooks_middleware.
python/PACKAGE_STATUS.md Documents the new experimental feature and its opt-in extra.

Review details

  • Files reviewed: 8/9 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread python/packages/core/agent_framework/_agent_hooks.py Outdated
Comment thread python/packages/core/pyrightconfig.dependency.json
The pre-commit pyupgrade hook rewrites the quoted forward reference;
ResponseStream is imported at runtime in this module, so the quotes
were unnecessary.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
Comment thread python/packages/core/agent_framework/_agent_hooks.py Outdated
Comment thread python/packages/core/agent_framework/_agent_hooks.py Outdated
Comment thread python/packages/core/agent_framework/_agent_hooks.py Outdated
Comment thread python/packages/core/agent_framework/_agent_hooks.py Outdated
Comment thread python/packages/core/agent_framework/_agent_hooks.py Outdated
Comment thread python/packages/core/agent_framework/_agent_hooks.py
Comment thread python/packages/core/agent_framework/_agent_hooks.py
Comment thread python/packages/core/agent_framework/_agent_hooks.py Outdated
Comment thread python/packages/core/agent_framework/_agent_hooks.py Outdated
Comment thread python/packages/core/agent_framework/_agent_hooks.py Outdated
Reworks the agent-hooks feature per PR review:

- Verdicts now precede durability: a run-scoped persistence gate
  (_sessions.py) defers per-service-call history persistence and
  after-run provider work until the covering post_model_call/output
  verdict permits; denied content never persists, transforms persist
  post-write-back. Unhooked runs are unchanged (verified against an
  instrumented baseline).
- ResponseStream.buffered_and_gated: a buffered-gate combinator that
  applies the run's pending stream hooks before the gate, then seals
  the stream, so no middleware can rewrite egress after the output
  verdict. Replaces the hand-rolled replay iterator.
- MiddlewareBundle (public, _middleware.py): the factory returns an
  indivisible bundle categorize_middleware splits, making partial
  installs impossible by construction; members are validated at
  construction. Bare (non-sequence) middleware at agent construction
  is now normalized instead of silently dropped, and unrecognized
  middleware logs a warning instead of vanishing.
- Factory split and rename: create_agent_hooks_middleware (per-run
  sessions) and create_agent_hooks_middleware_from_emitter
  (host-owned); the sentinel parameter-diffing is gone.
- Wire conversions live in per-point codec classes owning to_wire and
  write_back. Fixes in that code: tool-call name transforms apply or
  raise; non-object args transforms raise; argument write-back merges
  only changed keys (original values, including bytes, preserved by
  identity); message-list write-back matches by identity, not index.
- function_approval_request objects on the normal return path pass
  through un-emitted, preserving the human approval pause.
- Hosted (service-executed) tool calls surface in the post_model_call
  content projection; the tool-seam limitation is documented.
- Import probe covers the full SDK surface and re-raises as
  missing-extra only for the agent_hooks module; module logger added;
  _json_safe replaced by make_json_safe (which gained bytes support);
  tools_registered uses normalize_tools; dependency-pyright analyzes
  the module again via the test dependency-group.
- Tests: 75 in the feature suite (persistence gating, stream-hook
  sealing, approval passthrough, codec units, bundle validation,
  bare-bundle installs), full core suite green.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>

@moonbox3 Evan Mattson (moonbox3) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have a look at the failing CI/CD (code quality checks) too, please. Thanks.

Comment thread python/packages/core/agent_framework/_sessions.py Outdated
Comment thread python/packages/core/agent_framework/_types.py
Comment thread python/packages/core/agent_framework/_middleware.py
Comment thread python/packages/core/agent_framework/_agent_hooks.py Outdated
Comment thread python/packages/core/agent_framework/_agent_hooks.py Outdated
Comment thread python/packages/core/agent_framework/_agent_hooks.py Outdated
Comment thread python/packages/core/agent_framework/_middleware.py Outdated
Comment thread python/packages/core/agent_framework/_middleware.py
Addresses the second review round on the agent-hooks feature:

- Nested-run persistence ownership: RawAgent.run stamps a run identity
  over the run's dynamic extent (including streaming pulls and result
  hooks); the persistence gate binds to its owning run via an
  offer/adopt handshake keyed to the agent instance and accepts only
  its owner's persists — nested runs persist inline regardless of how
  they were started (tool calls, middleware, custom run loops). The
  tool-seam suspension remains for custom-loop sub-agents invoked as
  tools; the one residual case (custom loop nested in a custom loop
  off the tool path) is fail-closed and documented. Fixes a latent
  pre-existing re-deferral: flush() now drains with the gate context
  suspended, so a nested hooked run's permitted after-run persistence
  no longer re-defers into an enclosing gate.
- as_tool stream_callback consumes the released (verdicted) stream;
  observers cannot see denied or pre-transform content. Both
  directions are regression-tested.
- categorize_middleware gained supported_categories: a bundle member
  landing in a category a call site cannot install raises; bare
  middleware warns like _add_middleware. Wired at the chat-client
  sites and the provider seam.
- ResponseStream.buffered_and_gated owns the re-derivation rule via a
  rederive callable (gates cannot choose released updates) and is
  marked experimental.
- Wire codecs compare with bool-aware equality (Python == equates
  1 == True, which made bool/number transforms look untouched and get
  dropped) and _ToolResultCodec.write_back owns the untouched-wire
  rule via the before value.
- middleware parameters accept a bare middleware or bundle everywhere
  the runtime does (constructors, run overloads, as_agent, telemetry
  and harness layers, foundry); the bare-source rule has a single
  owner in categorize_middleware; bare middleware assigned to the
  attribute now executes (documented behavior change).
- MiddlewareBundle is experimental and validates members; approval
  passthrough, typing-check fixes (ty ignores mypy-coded ignore
  comments), logging, and documentation updates per review.

Test count: 85 feature tests plus 12 new this round across sessions,
middleware, agents; full core suite green; typing checked under
mypy, pyrefly, ty, zuban, and pyright.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
Comment thread python/packages/core/agent_framework/_agent_hooks.py
Comment thread python/packages/core/agent_framework/_agents.py Outdated
Per review: docstrings describe current behavior only. The
bare-middleware behavior change stays recorded in the PR description
and commit history.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
Comment thread python/packages/core/agent_framework/_sessions.py
Comment thread python/packages/core/agent_framework/_harness/_agent.py Outdated
A retry or fallback middleware issuing a second call_next() gave the
new attempt a fresh run identity that the persistence gate's
first-bind-wins ownership rejected, so the retried attempt's history
persisted inline before the output verdict — a denied response became
durable again. The gate now accumulates every identity adopted
through its own offer ticket: all attempts' persistence stays behind
the one final verdict (deny drops all of it, allow flushes all of
it). Accumulation over rebind-replace is deliberate: rebinding would
flip an earlier attempt's still-running background work from deferred
to inline, which is the fail-open direction. A foreign agent still
cannot bind: tickets are minted only by the covered pipeline's final
handler and adoption is instance-keyed.

Also consolidates the bare-middleware-source rule into a single
_as_middleware_list owner used by every interpretation site (the
harness merge, BaseAgent.__init__, categorize_middleware, both
client-kwargs merges, get_response, SessionContext.extend_middleware),
including the str/bytes exclusion the stray copies missed. The
constructor now stores a copy of the caller's sequence; assign to the
middleware attribute for post-construction changes.

Retry regression tests cover denied and allowed retried runs in both
stream modes and fail with first-bind-wins restored.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
Comment thread python/packages/core/agent_framework/_agent_hooks.py Outdated
Comment thread python/packages/core/agent_framework/_sessions.py Outdated
The streaming agent seam ran call_next() outside the persistence
gate (only _consume entered it later), so a retry middleware that
drained a successful attempt with get_final_response() and discarded
it persisted that attempt's exchange before any verdict existed; a
later deny dropped only the retry attempt's deferred work. The
descent is now wrapped in the gate exactly like the non-streaming
seam: attempt identities adopted during descent are accepted owners,
so in-pipeline draining defers, deny drops every attempt, and a
middleware that raises after draining strands the pending persists
unexecuted. The bind_owner docstring now states the actual soundness
invariant covering both bind sites: every bind comes from a run
inside the covered pipeline.

New tests cover drained-and-discarded attempts (deny and allow, both
stream modes) and a sub-agent tool inside a drained attempt; the
streaming deny variant fails with the gate wrap reverted.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
Comment thread python/packages/core/agent_framework/_agent_hooks.py
…ation

With the pipeline descent now running inside the persistence gate, a
middleware that drains a successful attempt and then terminates
without a result left that attempt's deferred persistence stranded:
the streaming no-result termination path raised before any flush, so
history of exchanges that really happened and passed their own
verdicts quietly vanished (streaming only; non-streaming already
flushes before its re-raise). The path now flushes before re-raising
the termination, with a state.halted guard first so an enforcement
failure during the drained attempt still strands pending fail-closed
and surfaces the halt, mirroring the non-streaming ordering exactly.

The regression test covers both seams; the streaming variant fails
without the fix.

Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants